﻿// -----------------------------------------------------------------------
// <copyright file="Connection.cs" company="TaskyMedia LLC">
//  Copyright (c) TaskyMedia LLC
// </copyright>
// -----------------------------------------------------------------------

namespace SohpServer
{
    using System;
    using System.Collections.Concurrent;
    using System.IO;
    using System.Net;
    using System.Net.Sockets;
    using System.Threading;
    using SohpShared;

    /// <summary>
    /// Contains a client connection to a given host.
    /// </summary>
    internal class Connection : IDisposable
    {
        #region Constants

        /// <summary>
        /// The amount of time (in milliseconds) to wait after attempting to establish a connection
        /// before a timeout is assumed.
        /// </summary>
        private const int ConnectTimeout = 7500;

        /// <summary>
        /// The size of the buffer used to receive data on the socket.
        /// </summary>
        private const int ReceiveBufferSize = 4096;

        /// <summary>
        /// The amount of time (in milliseconds) to wait when a client channel for receive is not open before
        /// assuming the connection has been abadoned or dropped.
        /// </summary>
        private const int ReceiveChannelTimeout = 180000;

        #endregion

        #region Fields

        /// <summary>
        /// The connection identifier.
        /// </summary>
        private int connectionId;

        /// <summary>
        /// The starting <see cref="DateTime"/> for calculating the Receive Channel Timeout window.
        /// </summary>
        private DateTime receiveChannelTimeoutWindowStart;

        /// <summary>
        /// The connection secret; shared with the client but kept private.
        /// </summary>
        private long connectionSecret;

        /// <summary>
        /// The last request's timestamp.
        /// </summary>
        private long lastRequestTimestamp;

        /// <summary>
        /// Object used to synchronize access to the <c>receivedData</c> array.
        /// </summary>
        private object receivedDataLock;

        /// <summary>
        /// Data received from the external resource to be relayed to the client.
        /// </summary>
        private byte[] receivedData;

        /// <summary>
        /// Amount of data received from the external resource in the buffer.
        /// </summary>
        private int receivedDataLength;

        /// <summary>
        /// The thread responsible for monitoring the state of the connection.
        /// </summary>
        private Thread connectionMonitor;

        /// <summary>
        /// The actual socket connection to the external resource.
        /// </summary>
        private Socket socket;

        #endregion

        #region Events

        /// <summary>
        /// Occurs when the connection has been closed.
        /// </summary>
        public event EventHandler ConnectionClosed;

        #endregion

        #region Properties

        /// <summary>
        /// Gets the connection id.
        /// </summary>
        public int ConnectionId
        {
            get
            {
                return this.connectionId;
            }
        }

        /// <summary>
        /// Gets a value indicating whether this instance is connected.
        /// </summary>
        /// <value>
        ///     <c>true</c> if this instance is connected; otherwise, <c>false</c>.
        /// </value>
        private bool IsConnected
        {
            get
            {
                return this.socket != null && this.socket.Connected;
            }
        }

        #endregion

        #region Public Methods

        /// <summary>
        /// Attempts to connect to the specified host and port.
        /// </summary>
        /// <param name="host">The target host.</param>
        /// <param name="port">The target port.</param>
        /// <returns>Returns whether a connection was established.</returns>
        public bool Connect(string host, int port)
        {
            this.socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
            this.receivedData = new byte[1024];
            this.ResetInternalState();

            try
            {
                IAsyncResult result = this.socket.BeginConnect(host, port, null, null);
                bool didConnect = result.AsyncWaitHandle.WaitOne(Connection.ConnectTimeout, true);

                if (!didConnect)
                {
                    try
                    {
                        this.socket.Close();
                        this.socket.Dispose();
                    }
                    finally
                    {
                        this.socket = null;
                    }
                }
                else
                {
                    this.receiveChannelTimeoutWindowStart = DateTime.Now;
                    this.connectionMonitor = new Thread(this.MonitorConnectionTimeout);
                    this.connectionMonitor.Start();
                    this.StartReceiveData();
                }

                return didConnect;
            }
            catch (SocketException)
            {
                return false;
            }
            catch (Exception)
            {
                throw;
            }
        }

        /// <summary>
        /// Sets the connection parameters.
        /// </summary>
        /// <param name="newConnectionId">The connection id.</param>
        /// <param name="newConnectionSecret">The shared connection secret.</param>
        /// <param name="adjustedTimeStamp">The adjusted time stamp (the initial LRT).</param>
        public void SetConnectionParams(int newConnectionId, long newConnectionSecret, long adjustedTimeStamp)
        {
            this.connectionId = newConnectionId;
            this.connectionSecret = newConnectionSecret;
            this.lastRequestTimestamp = adjustedTimeStamp;
        }

        /// <summary>
        /// Handles the operation request.
        /// </summary>
        /// <param name="reqContext">The request context.</param>
        /// <param name="adjustedTimestamp">The adjusted timestamp.</param>
        /// <param name="signature">The signature of the request.</param>
        /// <returns>Returns <c>true</c> if the request was well-formed and able to be processed; otherwise, <c>false</c></returns>
        public bool HandleOperationRequest(HttpListenerContext reqContext, long adjustedTimestamp, string signature)
        {
            string requestOperation = string.Empty;

            if (adjustedTimestamp < this.lastRequestTimestamp)
            {
                return false;
            }
            else
            {
                if (reqContext.Request.Url.AbsolutePath.EndsWith("/close", StringComparison.OrdinalIgnoreCase))
                {
                    requestOperation = "CLOSE";
                }
                else if (reqContext.Request.Url.AbsolutePath.EndsWith("/send", StringComparison.OrdinalIgnoreCase))
                {
                    requestOperation = "SEND";
                }
                else if (reqContext.Request.Url.AbsolutePath.EndsWith("/receive", StringComparison.OrdinalIgnoreCase))
                {
                    requestOperation = "RECEIVE";
                }

                string expectedSignature = RequestSigning.GenerateSignature(requestOperation, adjustedTimestamp, this.connectionSecret);
                if (string.Equals(signature, expectedSignature))
                {
                    switch (requestOperation)
                    {
                        case "CLOSE":
                            this.HandleCloseOperation(reqContext);
                            break;
                        case "SEND":
                            this.HandleSendOperation(reqContext);
                            break;
                        case "RECEIVE":
                            this.HandleReceiveOperation(reqContext);
                            break;
                    }

                    return true;
                }
                else
                {
                    return false;
                }
            }
        }

        #endregion

        #region IDisposable Implementation

        /// <summary>
        /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
        /// </summary>
        public void Dispose()
        {
            Dispose(true);
            GC.SuppressFinalize(this);
        }

        /// <summary>
        /// Releases unmanaged and - optionally - managed resources
        /// </summary>
        /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; 
        /// <c>false</c> to release only unmanaged resources.</param>
        protected virtual void Dispose(bool disposing)
        {
            if (disposing)
            {
                // free managed resources
                if (this.socket != null)
                {
                    this.socket.Dispose();
                    this.socket = null;
                }
            }
        }

        #endregion

        #region Private Methods

        #region Operation Handlers

        /// <summary>
        /// Handles the Send operation.
        /// </summary>
        /// <param name="reqContext">The request context.</param>
        private void HandleSendOperation(HttpListenerContext reqContext)
        {
            byte[] toSend = new byte[reqContext.Request.ContentLength64];
            int numBytesReceived = reqContext.Request.InputStream.Read(toSend, 0, toSend.Length);

            if (this.IsConnected)
            {
                try
                {
                    this.socket.Send(toSend, 0, numBytesReceived, SocketFlags.None);
                    ProxyServer.SendOKResponse(reqContext);
                }
                catch (SocketException)
                {
                    ProxyServer.SendSocketNotFoundResponse(reqContext);
                    this.OnConnectionClosed();
                }
            }
            else
            {
                ProxyServer.SendSocketNotFoundResponse(reqContext);
            }
        }

        /// <summary>
        /// Handles the Close operation.
        /// </summary>
        /// <param name="reqContext">The request context.</param>
        private void HandleCloseOperation(HttpListenerContext reqContext)
        {
            this.OnConnectionClosed();
            ProxyServer.SendOKResponse(reqContext);
        }

        /// <summary>
        /// Handles the Receive operation.
        /// </summary>
        /// <param name="reqContext">The request context.</param>
        private void HandleReceiveOperation(HttpListenerContext reqContext)
        {
            if (this.receivedDataLength == 0)
            {
                DateTime waitStart = DateTime.Now;
                this.receiveChannelTimeoutWindowStart = DateTime.Now;

                while (this.receivedDataLength == 0 && (DateTime.Now - waitStart).TotalSeconds < 45)
                {
                    Thread.Sleep(0);
                }
            }

            lock (this.receivedDataLock)
            {
                if (this.receivedDataLength > 0)
                {
                    reqContext.Response.StatusCode = 200;
                    reqContext.Response.StatusDescription = "OK";
                    reqContext.Response.ContentLength64 = this.receivedDataLength;
                    reqContext.Response.ContentType = "application/x-sohp-blob";
                    reqContext.Response.OutputStream.Write(this.receivedData, 0, this.receivedDataLength);
                    reqContext.Response.Close();

                    this.receivedData = new byte[1024];
                    this.receivedDataLength = 0;
                    this.receiveChannelTimeoutWindowStart = DateTime.Now;
                }
                else
                {
                    reqContext.Response.StatusCode = 200;
                    reqContext.Response.StatusDescription = "OK";
                    reqContext.Response.ContentLength64 = 0;
                    reqContext.Response.Close();
                }
            }
        }

        #endregion

        #region Socket Methods

        /// <summary>
        /// Starts to receive data on the socket.
        /// </summary>
        private void StartReceiveData()
        {
            if (this.socket != null && this.socket.Connected)
            {
                byte[] buffer = new byte[Connection.ReceiveBufferSize];
                this.socket.BeginReceive(buffer, 0, buffer.Length, SocketFlags.None, this.OnReceiveData, buffer);
            }
        }

        /// <summary>
        /// Called when data is received on the socket.
        /// </summary>
        /// <param name="asyncResult">The async result.</param>
        private void OnReceiveData(IAsyncResult asyncResult)
        {
            if (this.socket == null)
            {
                if (this.IsConnected)
                {
                    this.OnConnectionClosed();
                }

                return;
            }

            int numBytes = 0;
            try
            {
                numBytes = this.socket.EndReceive(asyncResult);
            }
            catch (SocketException)
            {
                this.OnConnectionClosed();
            }

            if (numBytes > 0)
            {
                // Read the data from the buffer
                byte[] buffer = (byte[])asyncResult.AsyncState;

                // Add the data to the output queue
                lock (this.receivedDataLock)
                {
                    if (numBytes >= this.receivedData.Length - this.receivedDataLength)
                    {
                        byte[] oldData = new byte[this.receivedData.Length + (numBytes * 2)];
                        Array.Copy(this.receivedData, 0, oldData, 0, this.receivedDataLength);
                        this.receivedData = oldData;
                    }

                    Array.Copy(buffer, 0, this.receivedData, this.receivedDataLength, numBytes);
                    this.receivedDataLength += numBytes;
                }

                // Receive more data
                this.StartReceiveData();
            }
            else
            {
                this.OnConnectionClosed();
            }
        }

        /// <summary>
        /// Called when the socket connection is closed.
        /// </summary>
        private void OnConnectionClosed()
        {
            if (this.socket != null)
            {
                this.socket.Close();
                this.socket.Dispose();
                this.socket = null;
            }

            if (this.connectionSecret > 0 && this.ConnectionClosed != null)
            {
                this.ConnectionClosed(this, EventArgs.Empty);
            }

            this.ResetInternalState();
        }

        #endregion

        /// <summary>
        /// Dispatches the received data.
        /// </summary>
        private void MonitorConnectionTimeout()
        {
            while (this.IsConnected)
            {
                if ((DateTime.Now - this.receiveChannelTimeoutWindowStart).TotalMilliseconds > Connection.ReceiveChannelTimeout)
                {
                    this.OnConnectionClosed();
                }

                Thread.Sleep(1000);
            }
        }

        /// <summary>
        /// Resets the internal state of the connection object.
        /// </summary>
        private void ResetInternalState()
        {
            this.connectionId = 0;
            this.connectionSecret = 0;
            this.lastRequestTimestamp = 0;
            this.receiveChannelTimeoutWindowStart = DateTime.MinValue;
            this.connectionMonitor = null;
            this.receivedDataLock = new object();
            this.receivedData = new byte[1024];
            this.receivedDataLength = 0;
        }

        #endregion
    }
}
